D:\a\csshw\csshw\xtask\src\social_preview.rs
Line | Count | Source |
1 | | //! Social preview image generation. |
2 | | //! |
3 | | //! Orchestrates `docker run` against the pinned Playwright image to render |
4 | | //! `templates/social-preview.html` into a 1280×640 PNG with live data |
5 | | //! fetched from the GitHub API. The Rust side is a thin shell: all HTTP, |
6 | | //! template substitution, and screenshotting live in |
7 | | //! `xtask/social-preview/generate.mjs`, which runs inside the container. |
8 | | //! |
9 | | //! The host only needs Rust, Cargo, and Docker. No host-side Node.js, npm, |
10 | | //! or Playwright installation is required. |
11 | | |
12 | | use std::path::{Path, PathBuf}; |
13 | | |
14 | | use anyhow::{bail, Context, Result}; |
15 | | |
16 | | /// Pinned Playwright Docker image tag. |
17 | | /// |
18 | | /// The numeric portion (e.g. `v1.59.1`) must match `@playwright/test` in |
19 | | /// `xtask/social-preview/package.json`. Playwright refuses to run when |
20 | | /// these versions diverge, so bump both in the same commit. See |
21 | | /// `xtask/social-preview/README.md` for details. |
22 | | const PLAYWRIGHT_IMAGE: &str = "mcr.microsoft.com/playwright:v1.59.1-noble"; |
23 | | |
24 | | /// Default output path for the generated PNG, relative to the workspace |
25 | | /// root. Lives under `target/` so it shares Cargo's build-artifact |
26 | | /// directory and inherits its `.gitignore` entry. |
27 | | const DEFAULT_OUT: &str = "target/social-preview/social-preview.png"; |
28 | | |
29 | | /// Container-side mount point for the workspace. |
30 | | const CONTAINER_WORKSPACE: &str = "/workspace"; |
31 | | |
32 | | /// All side-effecting operations required by this module. |
33 | | /// |
34 | | /// Implement with mocks in tests to achieve zero docker, filesystem, |
35 | | /// process, and network side-effects. |
36 | | pub trait SocialPreviewSystem { |
37 | | /// Return the absolute path to the workspace root (parent of `xtask/`). |
38 | | /// |
39 | | /// # Errors |
40 | | /// |
41 | | /// Returns an error if the workspace root cannot be resolved. |
42 | | fn workspace_root(&self) -> Result<PathBuf>; |
43 | | |
44 | | /// Read an environment variable, returning `None` when unset or empty. |
45 | | fn env_var(&self, key: &str) -> Option<String>; |
46 | | |
47 | | /// Ensure the parent directory of `path` exists, creating it (and any |
48 | | /// missing ancestors) if necessary. |
49 | | /// |
50 | | /// # Arguments |
51 | | /// |
52 | | /// * `path` - File path whose parent directory must exist. |
53 | | /// |
54 | | /// # Errors |
55 | | /// |
56 | | /// Returns an error if the directory cannot be created. |
57 | | fn ensure_parent_dir(&self, path: &Path) -> Result<()>; |
58 | | |
59 | | /// Verify that `docker` is installed on `PATH` and that its daemon is |
60 | | /// reachable. Called before any `docker run` invocation so the user |
61 | | /// gets a helpful message instead of a raw pipe/socket error. |
62 | | /// |
63 | | /// # Errors |
64 | | /// |
65 | | /// Returns an error describing whether the binary is missing or the |
66 | | /// daemon is not running. |
67 | | fn check_docker_ready(&self) -> Result<()>; |
68 | | |
69 | | /// Return `true` when `image` is already present in the local image |
70 | | /// cache (i.e. `docker image inspect <image>` succeeds). |
71 | | fn docker_image_exists(&self, image: &str) -> bool; |
72 | | |
73 | | /// Run `docker pull <image>` with inherited stdio so the user sees |
74 | | /// layer-download progress. |
75 | | /// |
76 | | /// # Errors |
77 | | /// |
78 | | /// Returns an error if `docker pull` exits with a non-zero status. |
79 | | fn docker_pull(&self, image: &str) -> Result<()>; |
80 | | |
81 | | /// Invoke `docker` with the given argument list and environment. |
82 | | /// |
83 | | /// # Arguments |
84 | | /// |
85 | | /// * `args` - Arguments passed to `docker` (starting with the |
86 | | /// subcommand, e.g. `run`). |
87 | | /// * `envs` - Additional `(key, value)` environment variables applied |
88 | | /// to the spawned `docker` process; these are forwarded to the |
89 | | /// container via explicit `-e` flags built into `args`. |
90 | | /// |
91 | | /// # Errors |
92 | | /// |
93 | | /// Returns an error if the process cannot be started or exits with a |
94 | | /// non-zero status. |
95 | | fn run_docker(&self, args: &[String], envs: &[(String, String)]) -> Result<()>; |
96 | | |
97 | | /// Print an informational message to stdout. |
98 | | fn print_info(&self, message: &str); |
99 | | |
100 | | /// Print a debug-level message. Intended for low-level command |
101 | | /// traces (e.g. the exact `docker` invocation) that would be noisy by |
102 | | /// default but useful when troubleshooting. The production |
103 | | /// implementation only emits the message when `CSSHW_XTASK_VERBOSE` |
104 | | /// is set to a non-empty value. |
105 | | fn print_debug(&self, message: &str); |
106 | | } |
107 | | |
108 | | /// Production implementation of [`SocialPreviewSystem`]. |
109 | | pub struct RealSystem; |
110 | | |
111 | | #[cfg_attr(coverage_nightly, coverage(off))] |
112 | | impl SocialPreviewSystem for RealSystem { |
113 | | fn workspace_root(&self) -> Result<PathBuf> { |
114 | | // CARGO_MANIFEST_DIR is set by Cargo when building this binary; it |
115 | | // points at xtask/, whose parent is the workspace root. |
116 | | let manifest_dir = env!("CARGO_MANIFEST_DIR"); |
117 | | let root = Path::new(manifest_dir) |
118 | | .parent() |
119 | | .context("failed to resolve workspace root from CARGO_MANIFEST_DIR")? |
120 | | .to_path_buf(); |
121 | | Ok(root) |
122 | | } |
123 | | |
124 | | fn env_var(&self, key: &str) -> Option<String> { |
125 | | std::env::var(key).ok().filter(|v| !v.is_empty()) |
126 | | } |
127 | | |
128 | | fn ensure_parent_dir(&self, path: &Path) -> Result<()> { |
129 | | if let Some(parent) = path.parent() { |
130 | | std::fs::create_dir_all(parent) |
131 | | .with_context(|| format!("failed to create directory {}", parent.display()))?; |
132 | | } |
133 | | Ok(()) |
134 | | } |
135 | | |
136 | | fn check_docker_ready(&self) -> Result<()> { |
137 | | // `docker info` is cheap and exercises both the CLI resolution |
138 | | // path and a round-trip to the daemon socket. |
139 | | let output = match std::process::Command::new("docker") |
140 | | .args(["info", "--format", "{{.ServerVersion}}"]) |
141 | | .output() |
142 | | { |
143 | | Ok(o) => o, |
144 | | Err(e) if e.kind() == std::io::ErrorKind::NotFound => { |
145 | | bail!( |
146 | | "`docker` was not found on PATH. Install Docker Desktop (or the Docker Engine) and ensure `docker` is on your PATH." |
147 | | ); |
148 | | } |
149 | | Err(e) => { |
150 | | return Err(e).context("failed to spawn `docker info`"); |
151 | | } |
152 | | }; |
153 | | if output.status.success() && !output.stdout.is_empty() { |
154 | | return Ok(()); |
155 | | } |
156 | | let stderr = String::from_utf8_lossy(&output.stderr); |
157 | | bail!( |
158 | | "Docker is installed but its daemon is not reachable. Start Docker Desktop (or your Docker daemon) and try again.\n docker info stderr: {}", |
159 | | stderr.trim() |
160 | | ); |
161 | | } |
162 | | |
163 | | fn docker_image_exists(&self, image: &str) -> bool { |
164 | | std::process::Command::new("docker") |
165 | | .args(["image", "inspect", image]) |
166 | | .stdout(std::process::Stdio::null()) |
167 | | .stderr(std::process::Stdio::null()) |
168 | | .status() |
169 | | .map(|s| s.success()) |
170 | | .unwrap_or(false) |
171 | | } |
172 | | |
173 | | fn docker_pull(&self, image: &str) -> Result<()> { |
174 | | let status = std::process::Command::new("docker") |
175 | | .args(["pull", image]) |
176 | | .status() |
177 | | .with_context(|| format!("failed to spawn `docker pull {image}`"))?; |
178 | | if !status.success() { |
179 | | bail!("`docker pull {image}` failed with status {status}"); |
180 | | } |
181 | | Ok(()) |
182 | | } |
183 | | |
184 | | fn run_docker(&self, args: &[String], envs: &[(String, String)]) -> Result<()> { |
185 | | let mut command = std::process::Command::new("docker"); |
186 | | command.args(args); |
187 | | for (key, value) in envs { |
188 | | command.env(key, value); |
189 | | } |
190 | | let status = command |
191 | | .status() |
192 | | .context("failed to spawn `docker`; is Docker installed and on PATH?")?; |
193 | | if !status.success() { |
194 | | bail!("`docker {}` failed with status {status}", args.join(" ")); |
195 | | } |
196 | | Ok(()) |
197 | | } |
198 | | |
199 | | fn print_info(&self, message: &str) { |
200 | | println!("INFO - {message}"); |
201 | | } |
202 | | |
203 | | fn print_debug(&self, message: &str) { |
204 | | if std::env::var("CSSHW_XTASK_VERBOSE") |
205 | | .map(|v| !v.is_empty()) |
206 | | .unwrap_or(false) |
207 | | { |
208 | | eprintln!("DEBUG - {message}"); |
209 | | } |
210 | | } |
211 | | } |
212 | | |
213 | | /// Split the caller-supplied `--out` into (host-absolute path, workspace- |
214 | | /// relative path with forward slashes). |
215 | | /// |
216 | | /// Accepts any path. Relative paths resolve against the workspace root; |
217 | | /// absolute paths are used as-is. Lexical `..` components are normalised |
218 | | /// so inputs like `sub/../preview.png` are supported. The final resolved |
219 | | /// path must still live under `workspace_root` so the container bind mount |
220 | | /// can reach it at `/workspace/<rel>`; paths outside the workspace are |
221 | | /// rejected with a clear error. |
222 | 11 | fn resolve_out_paths(workspace_root: &Path, out: Option<PathBuf>) -> Result<(PathBuf, String)> { |
223 | 11 | let raw = out.unwrap_or_else(|| PathBuf::from7 (DEFAULT_OUT)); |
224 | 11 | let joined = if raw.is_absolute() { |
225 | 1 | raw.clone() |
226 | | } else { |
227 | 10 | workspace_root.join(&raw) |
228 | | }; |
229 | 11 | let normalised = normalise_path(&joined); |
230 | 11 | let rel9 = normalised.strip_prefix(workspace_root).map_err(|_| {2 |
231 | 2 | anyhow::anyhow!( |
232 | | "--out must resolve to a path inside the workspace root ({}); got {}", |
233 | 2 | workspace_root.display(), |
234 | 2 | raw.display() |
235 | | ) |
236 | 2 | })?; |
237 | 9 | let rel_str = rel.to_string_lossy().replace('\\', "/"); |
238 | 9 | Ok((normalised.clone(), rel_str)) |
239 | 11 | } |
240 | | |
241 | | /// Lexically normalise a path by collapsing `.` and `..` components |
242 | | /// without touching the filesystem. Behaves like `Path::canonicalize` |
243 | | /// minus the requirement that the path exist. `..` at the root is |
244 | | /// dropped (matching POSIX semantics). |
245 | 11 | fn normalise_path(path: &Path) -> PathBuf { |
246 | | use std::path::Component; |
247 | 11 | let mut out = PathBuf::new(); |
248 | 45 | for comp in path11 .components11 () { |
249 | 45 | match comp { |
250 | | Component::ParentDir => { |
251 | | // Only pop if the last pushed component is a regular |
252 | | // segment; otherwise drop (root `..`) or keep (leading |
253 | | // `..` on a relative path). |
254 | 2 | let popped = match out.components().next_back() { |
255 | | Some(Component::Normal(_)) => { |
256 | 2 | out.pop(); |
257 | 2 | true |
258 | | } |
259 | 0 | _ => false, |
260 | | }; |
261 | 2 | if !popped && !path.is_absolute()0 { |
262 | 0 | out.push(".."); |
263 | 2 | } |
264 | | } |
265 | 0 | Component::CurDir => {} |
266 | 43 | other => out.push(other.as_os_str()), |
267 | | } |
268 | | } |
269 | 11 | out |
270 | 11 | } |
271 | | |
272 | | /// Render a `docker` argument list as a single shell-quoted string, purely |
273 | | /// for diagnostic logging. Arguments containing whitespace or shell |
274 | | /// metacharacters are wrapped in single quotes; inner single quotes are |
275 | | /// escaped as `'\''`. This is never re-parsed — it's only printed to |
276 | | /// stdout so a user can copy-paste the exact invocation. |
277 | 8 | fn shell_quote_args(args: &[String]) -> String { |
278 | 8 | args.iter() |
279 | 100 | .map8 (|a| { |
280 | 100 | if a.is_empty() |
281 | 1.16k | || a.chars()100 .any100 (|c| { |
282 | 1.16k | c.is_whitespace() |
283 | 1.15k | || matches!( |
284 | 1.16k | c, |
285 | | '\'' | '"' |
286 | | | '$' |
287 | | | '`' |
288 | | | '\\' |
289 | | | '&' |
290 | | | '|' |
291 | | | ';' |
292 | | | '<' |
293 | | | '>' |
294 | | | '(' |
295 | | | ')' |
296 | | | '{' |
297 | | | '}' |
298 | | | '*' |
299 | | | '?' |
300 | | | '#' |
301 | | | '!' |
302 | | | '[' |
303 | | | ']' |
304 | | ) |
305 | 1.16k | }) |
306 | | { |
307 | 8 | format!("'{}'", a.replace('\'', "'\\''")) |
308 | | } else { |
309 | 92 | a.clone() |
310 | | } |
311 | 100 | }) |
312 | 8 | .collect::<Vec<_>>() |
313 | 8 | .join(" ") |
314 | 8 | } |
315 | | |
316 | | /// Build the `docker run` argument list for the generator script. |
317 | 8 | fn build_docker_args(workspace_root: &Path, container_out: &str, has_token: bool) -> Vec<String> { |
318 | 8 | let mount = format!( |
319 | | "{}:{CONTAINER_WORKSPACE}", |
320 | 8 | workspace_root.to_string_lossy().replace('\\', "/") |
321 | | ); |
322 | 8 | let mut args: Vec<String> = vec![ |
323 | 8 | "run".into(), |
324 | 8 | "--rm".into(), |
325 | 8 | "-v".into(), |
326 | 8 | mount, |
327 | 8 | "-w".into(), |
328 | 8 | CONTAINER_WORKSPACE.into(), |
329 | 8 | "-e".into(), |
330 | 8 | format!("OUT_PATH={container_out}"), |
331 | | ]; |
332 | 8 | if has_token { |
333 | 2 | args.push("-e".into()); |
334 | 2 | args.push("GITHUB_TOKEN".into()); |
335 | 6 | } |
336 | 8 | args.push(PLAYWRIGHT_IMAGE.into()); |
337 | 8 | args.push("sh".into()); |
338 | 8 | args.push("-c".into()); |
339 | | // Install node_modules on first run, then invoke the generator. We |
340 | | // use `npm ci` (not `npm install`) so the install is strictly driven |
341 | | // by the committed `package-lock.json`; this keeps runs reproducible |
342 | | // and prevents the bind-mounted workspace from picking up lockfile |
343 | | // mutations. Subsequent runs skip the install entirely and stay |
344 | | // offline. |
345 | | // |
346 | | // The install runs in a subshell so it does not alter the CWD of the |
347 | | // subsequent `node` invocation. `generate.mjs` resolves its inputs |
348 | | // (template, logo, font, linguist colors) as workspace-relative paths, |
349 | | // so it must run from `/workspace` — not from |
350 | | // `/workspace/xtask/social-preview`. |
351 | 8 | args.push( |
352 | 8 | "( cd xtask/social-preview && { [ -d node_modules ] || npm ci; } ) && node xtask/social-preview/generate.mjs" |
353 | 8 | .into(), |
354 | | ); |
355 | 8 | args |
356 | 8 | } |
357 | | |
358 | | /// Generate the social preview PNG. |
359 | | /// |
360 | | /// Resolves the output path, ensures the host-side output directory |
361 | | /// exists, and invokes the Playwright Docker container which runs |
362 | | /// `xtask/social-preview/generate.mjs` to fetch live GitHub data and |
363 | | /// render `templates/social-preview.html` to PNG. |
364 | | /// |
365 | | /// # Arguments |
366 | | /// |
367 | | /// * `system` - Injected I/O provider. |
368 | | /// * `out` - Optional output path override. Relative paths resolve against |
369 | | /// the workspace root. |
370 | | /// * `token` - Optional GitHub token override. Falls back to the |
371 | | /// `GITHUB_TOKEN` environment variable, then unauthenticated access. |
372 | | /// |
373 | | /// # Errors |
374 | | /// |
375 | | /// Returns an error if the workspace root cannot be resolved, the output |
376 | | /// directory cannot be created, or the `docker run` invocation fails. |
377 | 11 | pub fn generate_social_preview<S: SocialPreviewSystem>( |
378 | 11 | system: &S, |
379 | 11 | out: Option<PathBuf>, |
380 | 11 | token: Option<String>, |
381 | 11 | ) -> Result<()> { |
382 | 11 | let workspace_root = system.workspace_root()?0 ; |
383 | 11 | let (host_out9 , relative_out9 ) = resolve_out_paths(&workspace_root, out)?2 ; |
384 | 9 | system.print_info(&format!( |
385 | 9 | "Generating social preview → {}", |
386 | 9 | host_out.display() |
387 | 9 | )); |
388 | 9 | system.check_docker_ready()?1 ; |
389 | 8 | if !system.docker_image_exists(PLAYWRIGHT_IMAGE) { |
390 | 1 | system.print_info(&format!( |
391 | 1 | "Pulling Playwright image {PLAYWRIGHT_IMAGE} (first run only)" |
392 | 1 | )); |
393 | 1 | system.docker_pull(PLAYWRIGHT_IMAGE)?0 ; |
394 | 7 | } |
395 | 8 | system.ensure_parent_dir(&host_out)?0 ; |
396 | | |
397 | 8 | let container_out = format!("{CONTAINER_WORKSPACE}/{relative_out}"); |
398 | 8 | let resolved_token = token.or_else(|| system7 .env_var7 ("GITHUB_TOKEN"7 )); |
399 | 8 | let has_token = resolved_token.is_some(); |
400 | | |
401 | 8 | let args = build_docker_args(&workspace_root, &container_out, has_token); |
402 | 8 | let envs: Vec<(String, String)> = resolved_token |
403 | 8 | .into_iter() |
404 | 8 | .map(|t| ("GITHUB_TOKEN"2 .to_owned2 (), t2 )) |
405 | 8 | .collect(); |
406 | | |
407 | 8 | system.print_info(&format!("Starting Playwright container {PLAYWRIGHT_IMAGE}")); |
408 | 8 | system.print_debug(&format!("+ docker {}", shell_quote_args(&args))); |
409 | 8 | system.run_docker(&args, &envs)?1 ; |
410 | 7 | system.print_info(&format!("Wrote {}", host_out.display())); |
411 | 7 | Ok(()) |
412 | 11 | } |
413 | | |
414 | | #[cfg(test)] |
415 | | #[path = "tests/test_social_preview.rs"] |
416 | | mod tests; |